Skip to content

feat(storage): serve renditions, compiled CSS and templates through S3 asset storage - #37776

Open
swicken wants to merge 3 commits into
s3-stack/5-webdav-tempfrom
s3-stack/6-rendering
Open

swicken wants to merge 3 commits into
s3-stack/5-webdav-tempfrom
s3-stack/6-rendering

Conversation

@swicken

@swicken swicken commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

S3 asset storage, part 7 of 7. Stacked PRs, review bottom up. Each one builds on the one below.
1a storage layer #37770 · 1b binary asset API #37771 · 2 content #37772 · 3 recovery #37773 · 4 publishing #37774 · 5 temporary uploads and WebDAV #37775 · 6 rendering #37776
Everything is behind FEATURE_FLAG_S3_ASSET_STORAGE, off by default. With the flag off, behavior matches main.

Rebased on 2026-10-06 onto a fix in #37772 (3523198625). This PR's own commits are unchanged; the test counts below were taken before that rebase.

Refs #37868

Proposed Changes

  • Renditions. Completed image-filter outputs are uploaded to generated-assets and restored cold without re-running the filter; warm hits never contact S3, including JPEG output and filters that return their input unchanged. Rendition keys include the original's revision, so a replacement never serves the old rendition. Crops use the focal point from the content's metadata snapshot, with the Java and native engines pinned to the same coordinates. If uploading a produced rendition fails, the rendition is still served and stays local only; eviction never removes a file without a durable copy.
  • Compiled Sass is keyed by source and dependency bytes, site, mode, options and build, so a cold node restores matching CSS without compiling. A failed upload of compiled CSS is logged and the CSS is still served. The cache lease is held while sources are copied and stored output is read, not while Dart Sass runs.
  • Markdown, VTL and included files open through the FileAsset stream or the binary asset API, so an evicted file is restored before rendering. A storage failure on one of these file-backed resources is reported without caching a miss; resources built from the database keep the existing cached not-found behavior.

Behavior with the flag off

Unchanged from main; renditions keep the existing two-character layout and cache paths.

Review fixes

The last commit on this branch (fix(storage): keep renditions, compiled CSS and template loading available when S3 misbehaves) addresses a full review of this PR. All of it is flag-on only:

  • JpegImageFilter predicted a .png result while writing a .jpg, so JPEG renditions (the default Java engine) looked up S3 on every request, never restored from S3, and failed during an S3 outage. Filters that return their input unchanged had the same effect. The exporter now remembers, per node and bounded, predicted outputs that a no-op filter never writes, and uploads an output only when it was written at its predicted path, after the image permit is released.
  • $content.image.fpx, fpy and focalPoint work again for anonymous visitors: a permission denial or missing content means "no focal point", as on main, and only a storage failure is rethrown.
  • A failed upload no longer fails the request for renditions or compiled CSS, and no longer raises a UI error for a storage write failure.
  • The non-cached error for storage failures is limited to the file-backed Velocity loaders.
  • A missing VTL or included file outside the asset root is a plain not-found again, instead of a "POSSIBLE HACK ATTACK" warning.
  • The doc now says that a cold rendition read still fails during an S3 outage, that the rendition lease covers the whole export (narrowing it is a follow-up), and that superseded compiled CSS objects are not reclaimed yet.

Additional Info

With this PR merged, the stack carries the complete feature.

Checklist

  • Tests: the 229 unit tests in the doc's run command pass after the review fixes (2 skips: STS and native libvips), covering the whole stack, including the new ImageFilterExporterStorageTest, FocalPointAPIImplTest and StoredVelocityFileLoaderTest.
  • Integration after the review fixes, on this branch, flag off: 321 run, 0 failures, 29 S3-only skips, across the stack's integration classes (BinaryAssetStorageIntegrationTest, ContentletBackupStorageTest, SharedAssetStorageIntegrationTest, BinaryAssetStarterRestoreTest, PublishingArchiveStorageTest, DotWebdavHelperTest, AssetTemplateStorageTest, CSSAssetStorageTest, ContentFileAssetIntegrityCheckerTest) and the existing ContentletAPITest, FileMetadataAPITest, TempFileAPITest, BinaryCleanupJobTest, BinaryExporterServletTest, FocalPointAPITest and BundleResourceTest. Three cases of ContentletAPITest.testCheckin_nullRequiredFieldValue failed on the first pass and passed on rerun. The test saves a content type under a fixed variable while the previous case's content type is still being deleted asynchronously, and the stack changes no content-type code.
  • Integration after the review fixes, flag on against MinIO, the same classes without ContentletAPITest: 141 run, 1 failure. The failure was a test from 2 that still expected the servlet to hold its lease for the whole response; after updating it to expect the lease to end once the file is open, that class passed 14 of 14 on rerun.
  • Translations: not applicable.
  • Security Implications Contemplated: template and include loading keep their existing allowed-path checks, tightened to canonical-path prefix checks with the flag on.

@claude

claude Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @jcastro-dotcms's task in 2m 49s —— View job


Code Review

Reviewed the diff against origin/s3-stack/5-webdav-temp. Every new path is gated on AssetStorageFeature.isEnabled() with a faithful flag-off fallback, so default deployments are unaffected. I rechecked the three prior dotbot findings — all three are still present — and found no new correctness bugs. All findings below are flag-on only and non-blocking.

Existing (prior findings still present)

  • 🟡 Medium: DotLibSassCompiler.java:106 — With the flag on and live == true, the inode passed to find(...) is info.get().getLiveInode() with no null check. A file that has version info but no live version (never published, or published-then-unpublished) has a null getLiveInode(), so find(null, …) → fromContentlet(null) fails a request that compiled from the working inode on main. The flag-off branch always uses getWorkingInode() and never hits this. Guard it:

    final String inode = AssetStorageFeature.isEnabled() && live && info.get().getLiveInode() != null
            ? info.get().getLiveInode() : info.get().getWorkingInode();

    Fix this →

  • 🟡 Medium: WebAPI.java:410 — With the flag on, getAssetPath(...) returns null when getBinaryFile(conInode, BINARY_FIELD) returns null (JSON row records no binary for the field, or the restore misses), whereas the flag-off branch returns getRealAssetPath(...) unconditionally. Templates calling $webapi.getAssetPath(...) that previously received a path can now get null. Consider falling back to getRealAssetPath(conInode, file.getUnderlyingFileName()) when binaryFile == null so the contract matches the flag-off path.

  • 🟡 Medium: ImageFilterExporter.java:74-77 — The process-wide eviction read-lease (acquireCacheLease() → evictionLock.readLock()) is held for the whole export: all filters' pixel work, S3 up/downloads, and the image-semaphore wait. It is also redundant, since getGeneratedFile/storeGeneratedFile/openLocalFile each take the lease internally — the sibling CSS path deliberately scopes its lease to source copies and stored-output reads only. This starves the evictor's write lock for the duration of every concurrent render. The PR doc explicitly acknowledges this ("the rendition lease covers the whole export (narrowing it is a follow-up)"), so this is a known, accepted follow-up rather than a defect — noting it so it isn't lost.

Notes (verified correct, not findings)

  • JpegImageFilter.getResultsFile now predicts .jpg when the flag is on, matching what runFilter writes — this is the review fix for the JPEG-never-restores bug, and the UNPRODUCED_RESULTS bounded cache correctly handles no-op filters (ImageFilterExporter.java:204-214).
  • FocalPointAPIImpl.findContentletByInode (:201-208) rethrows only DotDataException under the flag; a permission denial / missing content still yields empty, so $content.image.fpx/fpy/focalPoint for anonymous visitors behaves as on main.
  • The file-backed Velocity loaders (VTLLoader, IncludeLoader, DotResourceLoader.readsStoredFile) propagate DotDataException without caching a miss, while DB-backed loaders keep the cached not-found behavior — consistent with the stated design.

No new critical/high issues. The flag-off behavior is byte-for-byte unchanged.

· branch s3-stack/6-rendering

@claude

claude Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request Unsafe to Rollback!!!

  • Category: H-5 — Binary Storage Provider Change (Storage provider configuration change)
  • Risk Level: 🟠 HIGH
  • Why it's unsafe: This PR extends the opt-in S3 asset-storage feature (AssetStorageFeature.isEnabled()) into CSS compilation caching, Velocity/VTL resource loading, and image-rendition/focal-point resolution. With the flag off (the default), every changed path falls back to byte-for-byte the same logic as before — the PR is safe for a standard, default deployment. But once an operator enables FEATURE_FLAG_S3_ASSET_STORAGE, this code starts writing new CSS/rendition caches and reading binaries through new S3-only key schemes. The PR's own new documentation says this plainly: "Enabling the flag is a one-way step for any content written while it is on... Neither a release without this code nor this release with the flag turned back off reads those revision keys..., so affected binaries resolve as stale or missing." That is exactly the H-5 failure mode: N-1 (or N with the flag re-disabled) looks for the binary/CSS-cache/rendition at the legacy path and finds nothing for anything written while the flag was on.
  • Code that makes it unsafe:
    • docs/testing/BINARY_S3_STORAGE.md lines 24-33 — "Enabling the flag is not rollback-safe" (self-documented by the PR).
    • dotCMS/src/main/java/com/dotcms/csspreproc/DotLibSassCompiler.java — compiledCacheFile(...) / storeCompiledOutput(...) (~lines 124-163) persist compiled CSS under a new S3-backed generated-assets cache key only reachable via AssetStorageFeature.isEnabled().
    • dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/business/exporter/ImageFilterExporter.java — cachedRendition(...) / s3Renditions() (~lines 658-668) resolve/store image renditions through BinaryAssetStorageAPI once the flag is on.
    • dotCMS/src/main/java/com/dotcms/rendering/velocity/services/{DotResourceLoader,IncludeLoader,VTLLoader}.java and viewtools/{MarkdownTool,WebAPI}.java — switch to reading VTL/markdown/file assets via APILocator.getBinaryAssetStorageAPI() instead of the local filesystem when the flag is on.
  • Alternative (if possible): Already largely applied per H-5's own guidance — every new path is flag-gated and falls back to legacy filesystem reads/writes when off, and legacy keys/paths remain readable (fallback-chain pattern). The residual risk is confined to sites that deliberately opt in, and is already disclosed in the doc rather than silently introduced. No additional code change is required; keep treating "flag enabled" as an explicit, forward-only operational decision and keep it out of default upgrade paths.

@swicken
swicken force-pushed the s3-stack/6-rendering branch from d55984c to d3e4707 Compare September 29, 2026 14:26
@swicken
swicken force-pushed the s3-stack/5-webdav-temp branch from 5ffcec2 to 00cb9e1 Compare September 29, 2026 14:26
@swicken
swicken force-pushed the s3-stack/5-webdav-temp branch from 00cb9e1 to a7e7291 Compare September 29, 2026 19:32
@swicken
swicken force-pushed the s3-stack/6-rendering branch from d3e4707 to 65be43b Compare September 29, 2026 19:32
@swicken
swicken force-pushed the s3-stack/6-rendering branch from 65be43b to 95368f9 Compare October 2, 2026 15:54
@swicken
swicken force-pushed the s3-stack/5-webdav-temp branch from a7e7291 to 187a09b Compare October 2, 2026 15:54
@swicken
swicken force-pushed the s3-stack/6-rendering branch from 95368f9 to ca2b3b5 Compare October 2, 2026 16:31
@swicken
swicken force-pushed the s3-stack/5-webdav-temp branch from 187a09b to 9422974 Compare October 2, 2026 16:31
@swicken
swicken marked this pull request as ready for review October 2, 2026 18:24
@swicken
swicken force-pushed the s3-stack/6-rendering branch from ca2b3b5 to 9bd3fa8 Compare October 6, 2026 14:43
@swicken
swicken force-pushed the s3-stack/5-webdav-temp branch from 9422974 to 0b1ed27 Compare October 6, 2026 14:43
@nollymar nollymar added the PR : dotbot review Trigger dotbot AI code review and the post-merge QA test plan label Oct 6, 2026
…3 asset storage

Final slice of the S3 asset storage work. With FEATURE_FLAG_S3_ASSET_STORAGE on,
completed image-filter outputs and compiled Sass are stored in the
generated-assets group and restored cold without recomputation, rendition keys
include the original's revision, crops use the snapshot focal point, and
Markdown, VTL and included files open through the binary asset API so evicted
files are restored before rendering. Flag-off rendering matches main.
…lable when S3 misbehaves

With S3 asset storage on, the JPEG filter now predicts the .jpg file it writes, so warm JPEG
renditions are found on local disk instead of making S3 requests on every request and failing
during an S3 outage. With the flag off the inherited .png prediction is kept, so flag-off cache
paths are unchanged.

When a filter returns its input unchanged (for example a maximum width larger than the image),
the exporter remembers on that node that the predicted output is never produced, so later
requests skip the S3 lookup for it, both per filter and for the chain's final output. Only an
output written at its predicted path is uploaded, and the upload now runs after the image permit
is released.

A failed rendition or compiled CSS upload no longer fails the request. The locally produced
output is served, a warning is logged, and the file stays local only, which eviction already
never removes. CSSAssetStorageTest now asserts this policy instead of the old fail-closed one.

The Sass compiler no longer holds the cache lease while Dart Sass runs; it holds it only while
copying sources and while reading a stored output. The rendition lease still spans the whole
export and is documented as a known limitation.

Focal-point reads treat a permission denial or missing content as no focal point again, as with
the flag off, so $content.image.fpx keeps working for anonymous visitors; only a DotDataException
from the lookup is rethrown.

DotResourceLoader reports a storage failure as a non-cached VelocityException only for the
file-backed loaders (VTL, macros, legacy VL and includes). Container, template, page and other
database loaders keep the cached ResourceNotFoundException.

VTLLoader and IncludeLoader only continue to the restore path for a missing file under the asset
root. A missing file elsewhere is a plain not-found again, instead of a "POSSIBLE HACK ATTACK"
warning.

Adds unit tests for the JPEG prediction, warm JPEG and no-op renditions during an outage, a failed
rendition upload, focal-point lookups and the Velocity loader failure handling, and updates
BINARY_S3_STORAGE.md.
throws BinaryContentExporterException {
if (s3Renditions()) {
try (var lease = APILocator.getBinaryAssetStorageAPI().acquireCacheLease()) {
return exportContentInternal(file, suppliedParameters);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P2] ImageFilterExporter.java:76 eviction lease held across full filter chain

Current code:

if (s3Renditions()) {
    try (var lease = APILocator.getBinaryAssetStorageAPI().acquireCacheLease()) {
        return exportContentInternal(file, suppliedParameters);
    }
}

Problem: The process-wide eviction read-lock (BinaryAssetStorageAPIImpl.acquireCacheLease() → evictionLock.readLock()) is held for the entire export: pixel work of every filter, S3 downloads/uploads, and the image-semaphore wait. This starves the evictor's write lock for the duration of all concurrent image renders, and is redundant because getGeneratedFile/storeGeneratedFile/openLocalFile already acquire the lease internally. The CSS path in this same PR deliberately scopes leases to source copies and stored-output reads only.

Fix:

return exportContentInternal(file, suppliedParameters);

and rely on the per-call leases already taken inside cachedRendition/storeRendition.

if (!com.dotcms.storage.AssetStorageFeature.isEnabled()) {
return APILocator.getFileAssetAPI().getRealAssetPath(conInode, file.getUnderlyingFileName());
}
final File binaryFile = APILocator.getBinaryAssetStorageAPI()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P2] WebAPI.java:408 getAssetPath returns null where flag-off path always returns a path

Current code:

final File binaryFile = APILocator.getBinaryAssetStorageAPI()
        .getBinaryFile(conInode, FileAssetAPI.BINARY_FIELD);
return binaryFile != null ? binaryFile.getAbsolutePath() : null;

Problem: With the flag on, getBinaryFile(inode, field) returns null when the row's contentlet_as_json records no binary for the field or the restore misses, while the flag-off path returns the legacy asset path unconditionally. Templates calling $webapi.getAssetPath(...) that previously received a path can now get null.

Fix:

return binaryFile != null ? binaryFile.getAbsolutePath()
        : APILocator.getFileAssetAPI().getRealAssetPath(conInode, file.getUnderlyingFileName());

Assumption: rows whose JSON lacks the fileAsset field are reachable here. What to verify: that getBinaryFile cannot return null for a FileAsset already resolved via fromContentlet above.


final FileAsset mainFile = APILocator.getFileAssetAPI()
.fromContentlet(APILocator.getContentletAPI().find(info.get().getWorkingInode(),
.fromContentlet(APILocator.getContentletAPI().find(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ [P3] DotLibSassCompiler.java:106 null live inode breaks live compile when live version is absent

Current code:

.fromContentlet(APILocator.getContentletAPI().find(
        com.dotcms.storage.AssetStorageFeature.isEnabled() && live
                ? info.get().getLiveInode() : info.get().getWorkingInode(),

Problem: With the flag on and live == true, a null getLiveInode() makes find(null, …) throw, failing a request that compiled from the working inode before.

Fix:

final String inode = com.dotcms.storage.AssetStorageFeature.isEnabled() && live
        && info.get().getLiveInode() != null
        ? info.get().getLiveInode() : info.get().getWorkingInode();

Assumption: CSSPreProcessServlet confirms a live asset for the identifier before compiling, so getLiveInode() should be non-null in practice. What to verify: that a published-then-unpublished SCSS file cannot reach this line in live mode; the null-guard removes the risk regardless.

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

dotbot code review:

  • Reviewer: meta/muse-spark-1.3 (medium)
  • Overall: patch is correct
  • New findings this run: 0
  • Prior unresolved dotbot findings still relevant: 0
  • Active findings total: 0

All new S3 paths are gated on AssetStorageFeature with legacy fallback preserved. No provable P0/P1 regression introduced by this patch was found in the rendition, CSS, VTL and focal-point flows.

Tip: comment with "/dotbot address comments" to attempt automated fixes for unresolved review threads.

reviewed by dotbot · meta/muse-spark-1.3 · medium


final FileAsset mainFile = APILocator.getFileAssetAPI()
.fromContentlet(APILocator.getContentletAPI().find(info.get().getWorkingInode(),
.fromContentlet(APILocator.getContentletAPI().find(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ [P3] DotLibSassCompiler.java:107 null live inode breaks live compile when live version is absent

Current code:

.fromContentlet(APILocator.getContentletAPI().find(
        com.dotcms.storage.AssetStorageFeature.isEnabled() && live
                ? info.get().getLiveInode() : info.get().getWorkingInode(),

Problem: With flag on and live, a null getLiveInode() makes find(null, …) throw, failing requests that previously compiled from the working inode.

Fix:

.fromContentlet(APILocator.getContentletAPI().find(
        com.dotcms.storage.AssetStorageFeature.isEnabled() && live
                && info.get().getLiveInode() != null
                ? info.get().getLiveInode() : info.get().getWorkingInode(),

Assumption: a live asset is normally confirmed before compiling, so this is defensive. What to verify: that a published-then-unpublished SCSS file cannot reach this line in live mode.

if (!com.dotcms.storage.AssetStorageFeature.isEnabled()) {
return APILocator.getFileAssetAPI().getRealAssetPath(conInode, file.getUnderlyingFileName());
}
final File binaryFile = APILocator.getBinaryAssetStorageAPI()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P2] WebAPI.java:410 getAssetPath returns null where flag-off path always returns a path

Current code:

final File binaryFile = APILocator.getBinaryAssetStorageAPI()
        .getBinaryFile(conInode, FileAssetAPI.BINARY_FIELD);
return binaryFile != null ? binaryFile.getAbsolutePath() : null;

Problem: Flag-on returns null when the JSON row lacks the binary field; flag-off always returns a path, changing the template contract.

Fix:

return binaryFile != null ? binaryFile.getAbsolutePath()
        : APILocator.getFileAssetAPI().getRealAssetPath(conInode, file.getUnderlyingFileName());

Assumption: rows without a recorded binary are reachable here. What to verify: that getBinaryFile cannot return null for a FileAsset resolved via fromContentlet above.

public BinaryContentExporterData exportContent(File file, final Map<String, String[]> parameters)
public BinaryContentExporterData exportContent(File file, final Map<String, String[]> suppliedParameters)
throws BinaryContentExporterException {
if (s3Renditions()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [P2] ImageFilterExporter.java:75 eviction lease held across full filter chain

Current code:

if (s3Renditions()) {
    try (var lease = APILocator.getBinaryAssetStorageAPI().acquireCacheLease()) {
        return exportContentInternal(file, suppliedParameters);
    }
}

Problem: Process-wide eviction read-lock is held across all filters' pixel work, S3 I/O, and semaphore waits, starving the evictor's write lock. getGeneratedFile/storeGeneratedFile/openLocalFile already take leases internally, so this is redundant.

Fix:

return exportContentInternal(file, suppliedParameters);

The PR doc acknowledges this is a known follow-up, so tracking it here to ensure it is not lost.

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

dotbot code review:

  • Reviewer: ~z-ai/glm-latest (medium)
  • Overall: patch is incorrect
  • New findings this run: 3
  • Prior unresolved dotbot findings still relevant: 0
  • Active findings total: 3

All new S3 asset-storage behavior is gated on AssetStorageFeature with legacy fallbacks when the flag is off, so default deployments are unaffected. The remaining issues are flag-on edge cases (null live inode, null asset-path return, and the acknowledged full-export eviction lease), not provable P0/P1 regressions.

Tip: comment with "/dotbot address comments" to attempt automated fixes for unresolved review threads.

reviewed by dotbot · ~z-ai/glm-latest · medium

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR : dotbot review Trigger dotbot AI code review and the post-merge QA test plan

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants